关于Java垃圾回收机制,网上介绍的资料很多,一般都是一些固定的知识,这些在书上都能找到相应的出处,这里希望讲一些更加细节的问题。如果你对Java虚拟机的知识不了解,建议先去了解一下,这里都是一些概念上的理解,主要基于HotSpot虚拟机。

GC(Garbage Collection)的发生时机

总体来说是内存使用紧张的时候会进行GC,即新对象所需内存比剩下的内存大时发生。

  1. young GC:当young gen中的eden区分配满的时候触发。注意young GC中有部分存活对象会晋升到old gen,所以young GC后old gen的占用量通常会有所升高。
  2. full GC:当准备要触发一次young GC时,如果发现统计数据说之前young GC的平均晋升大小比目前old gen剩余的空间大,则不会触发young GC而是转为触发full GC(因为HotSpot VM的GC里,除了CMS的concurrent collection之外,其它能收集old gen的GC都会同时收集整个GC堆,包括young gen,所以不需要事先触发一次单独的young GC);或者,如果有perm gen的话,要在perm gen分配空间但已经没有足够空间时,也要触发一次full GC;或者System.gc()、heap dump带GC,默认也是触发full GC。
  1. 并发GC的触发条件就不太一样。以CMS GC为例,它主要是定时去检查old gen的使用量,当使用量超过了触发比例就会启动一次CMS GC,对old gen做并发收集。

标记的过程

Stop the world

VM thread在进行GC前,必须要让所有的Java线程阻塞,从而stop the world,开始标记,不管什么算法的收集器,都需要有这个标记的过程,只是时间长短的问题。这一步是非常关键的一步,关于这个部分的内容的介绍也比较少。

How Stop the world

说起来很轻松,但虚拟机Stop the World的过程并不简单,因为发生GC的时刻是不确定的,而不同时刻虚拟机中堆内存中的对象,不同线程里JVM栈的栈帧中的局部变量表里的有效的局部变量(局部变量有作用域)所引用的对象处于不同的状态,而要让所有的线程在一个时间点全部停止,并且在这个时刻能得到线程里对象的状态,需要合理的规划。

这里采用的是安全点(safe point)和安全区域(safe region)的概念,safepoint安全点顾名思义是指一些特定的位置或时刻,当线程运行到这些位置时,线程的一些状态可以被确定,这里的状态确定是指在这个点虚拟机可以得到一些对象的确定状态,比如记录OopMap(一会会介绍OopMap)的状态,从而确定GC Root的信息,使JVM可以安全的进行一些操作。

在准备Stop the World时,其实就是相当于设置了一个中断标志位,而安全点其实就是一些指令,在JIT执行方式下,JIT编译的时候直接把safepoint的检查代码加入了生成的本地代码,不同线程运行到这条指令时会主动去检查这个标志位是否被设置,如果设置了就将线程停顿,否则就继续运行。

因为在safepoint上要记录OopMap的状态,确定GC Root的信息,这些都是在JIT编译的时候确定的,所以既要保证GC标志位设置的时候线程能很快进入到停止,避免程序长时间运行而不进入safepoint,又要避免safepoint设置过多而影响JIT的效率。

safepoint指的特定位置主要有:

  • 循环的末尾 (防止大循环的时候一直不进入safepoint,而其他线程在等待它进入safepoint)

  • 方法返回前

  • 调用方法的call之后

  • 抛出异常的位置

safepoint只能处理正在运行的线程,它们可以主动运行到safepoint。而一些Sleep或者被blocked的线程不能主动运行到safepoint。这些线程也需要在GC的时候被标记检查,JVM引入了safe region的概念。safe region是指一块区域,这块区域中的引用都不会被修改,比如线程被阻塞了,那么它的线程堆栈中的引用是不会被修改的,JVM可以安全地进行标记。线程进入到safe region的时候先标识自己进入了safe region,等它被唤醒准备离开safe region的时候,先检查能否离开,如果GC已经完成,那么可以离开,否则就在safe region呆在。这可以理解,因为如果GC还没完成,那么这些在safe region中的线程也是被stop the world所影响的线程的一部分,如果让他们可以正常执行了,可能会影响标记的结果

找出活的对象

标记的第一步就是得先获得GC roots,也就是根节点法,引用计数法因为循环引用的问题无法解决这里就不提了。所谓“GC roots”,或者说tracing GC的“根集合”,就是一组必须活跃的引用。GC roots是下面这些数据的集合:

  • JVM栈的栈帧中的局部变量表里的有效的局部变量(局部变量有作用域)所引用的对象
  • 方法区里面的类元素对象的static、常量所引用的对象
  • 运行时方法区里面的类元素对象的运行时常量所引用的对象
  • 本地方法栈里的引用所引用的对象

Tracing GC的根本思路就是:给定一个集合的引用作为根出发,通过引用关系遍历对象图,能被遍历到的(可到达的)对象就被判定为存活,其余对象(也就是没有被遍历到的)就自然被判定为死亡。

注意再注意:tracing GC的本质是通过找出所有活对象来把其余空间认定为“无用”,而不是找出所有死掉的对象并回收它们占用的空间。

GC roots这组引用是tracing GC的起点。要实现语义正确的tracing GC,就必须要能完整枚举出所有的GC roots,否则就可能会漏扫描应该存活的对象,导致GC错误回收了这些被漏扫的活对象。

而Jvm运行时的对象很多,如果一个个去遍历必然会花费大量的时间,获取GC roots最主要的部分在解决如何快速找到JVM栈的栈帧的局部变量表中的局部变量所引用的对象

在HotSpot里实现的方式从外部记录下类型信息,存成映射表。HotSpot把这样的数据结构叫做OopMap。要实现这种功能,需要虚拟机里的解释器和JIT编译器都有相应的支持,由它们来生成足够的元数据提供给GC。

在HotSpot中,对象的类型信息里有记录自己的OopMap,记录了在该类型的对象内什么偏移量上是什么类型的数据。这些数据是在类加载过程中计算得到的。

每个被JIT编译过后的方法也会在一些特定的位置记录下OopMap,记录了执行到该方法的某条指令的时候,栈上和寄存器里哪些位置是引用。这样GC在扫描栈的时候就会查询这些OopMap就知道哪里是引用了。而这些特定的位置主要在上面提到的安全点(safepoint)

清理的过程

如何清理需要垃圾收集器和收集算法的配合。

收集算法

常见的收集算法有复制法标记整理法(也叫标记-压缩算法),在新生代的对象每次垃圾收集都发现有大批对象死去,只有少量存活,选用复制算法最高效,将新生代的区域分为一个较大的Eden区和两个较小的Survivor,比例一般为8:1:1;每次触发Minor Gc时,将Eden区和其中一个Survivor区的对象复制到另一块Survivor区,也就是活着的对象在Survivor区来回复制,当一个对象在Survivor复制的次数达到一定数量时,不同的虚拟机有不同的次数,就会被被复制到老年代。老年代的GC 一般是Full GC,也叫 Major GC,指的是对老年代/永久代的stop the world的GC。一般情况下不会触发Eden的Minor Gc,可以配置,让Full GC之前先进行一次Minor GC,因为老年代很多对象都会引用到新生代的对象,先进行一次Minor GC可以提高老年代GC的速度。老年代采用的收集算法是标记整理法,一边清理无用的内存,一边整理活着的对象,使其内存对齐。

复制清理的优势是每次赋值过后的地址是对齐的,不需要再次整理,缺点是内存利用率低,尤其是如果有很多存活的对象情况下需要更多额外的内存。所以适合内存大部分是无用对象的区域,标记整理法适合对象稳定的区域,此算法避免了“标记-清除”的碎片问题,同时也避免了“复制”算法的空间问题。

收集器

垃圾收集器和收集算法不是冲突的,是结合完成的,不同的垃圾收集器会在不同的内存区域选择相应的垃圾收集算法完成清理。

Serial收集器

串行收集器主要有两个特点:第一,它仅仅使用单线程进行垃圾回收;第二,它独占式的垃圾回收。使用复制算法,优点是实现相对简单,逻辑处理特别高效,且没有线程切换的开销。适合单CPU处理器或者较小的应用内存,当JVM 在 Client 模式下运行时,它是默认的垃圾收集器

Parallel收集器

并行收集器,并行收集器是工作在新生代的垃圾收集器,它只简单地将串行回收器多线程化。它的回收策略、算法以及参数和串行回收器一样。
并行回收器也是独占式的回收器,在收集过程中,应用程序会全部暂停。但由于并行回收器使用多线程进行垃圾回收,因此,在并发能力比较强的 CPU 上,它产生的停顿时间要短于串行回收器,而在单 CPU 或者并发能力较弱的系统中,并行回收器的效果不会比串行回收器好,由于多线程的压力,它的实际表现很可能比串行回收器差。

CMS收集器

CMS 是Concurrent Mark Sweep 的缩写,意为并发标记清除,和Parallel收集器不同的在于只在标记的时候独占也就是stop the world,老年代收集器,致力于获取最短回收停顿时间(即缩短垃圾回收的时间),使用标记清除算法,多线程,优点是并发收集(用户线程可以和GC线程同时工作),停顿小。缺点是会产生内存碎片,可能需要再次GC来整理,也可以通过设置来避免。

对于 CMS,整个过程会有几步:

  1. 初始标记。
  2. 并发标记。
  3. 重新标记。
  4. 并发清除。

初始标记,stop the world,只标记能在GC roots直接找到的对象,时间很短,接着并发标记,根据初始标记来循环遍历所有活着的对象也就是tracing过程,这部分是用户线程并发进行的,重新标记是为了标记那些在并发标记过程中改变,漏掉的等对象,最后进行并发清除,当然其中还有更多的细节来保证GC的正确性,我就不是很了解了。

为什么初始标记不能也做成并发的?

而答案是:可以做成并发的,就是实现起来麻烦一些而已。Android Runtime(ART)编译器里的CMS实现在初始标记的时候采用了checkpointing做法,就不是完全stop-the-world而是一个个线程分别错开一点时间来暂停。这样系统在扫描一个线程的栈的时候其它线程还可以跑,比stop-the-world的影响小。

参考:

【深入理解Java虚拟机(第2版)——周志明 】

RednaxelaFX:java的gc为什么要分代?